Skip to content

Add Transparent Reader Support (TRS) - #324

Open
sidcha wants to merge 22 commits into
masterfrom
trs_dev
Open

Add Transparent Reader Support (TRS)#324
sidcha wants to merge 22 commits into
masterfrom
trs_dev

Conversation

@sidcha

@sidcha sidcha commented Jul 12, 2026

Copy link
Copy Markdown
Member

TRS tunnels raw smart-card APDUs from the CP, through a PD acting as a transparent pipe, to a card in the reader (OSDP CMD_XWR/REPLY_XRD, capability SMART_CARD_SUPPORT). The control panel drives the card directly, so a compromised reader cannot forge a credential.

The CP runs the session as a library-driven state machine: it negotiates transparent mode, streams the app's APDUs, and tears the session down on osdp_cp_trs_stop(). Apps submit OSDP_CMD_XWR commands and receive card responses as OSDP_EVENT_TRS events.

The PD answers each APDU either synchronously, in the command callback, or deferred: it ACKs "working" and delivers the response on a later poll once a slow card responds. Deferral keeps a shared multi-drop bus non-blocking, so each PD's session makes progress independently.

Gated behind OPT_BUILD_OSDP_TRS (default off); wired into both build systems and covered by a CP<->PD APDU round-trip unit test.

Co-developed-by: Aaron Tulino (Aaronjamt) git@aaronjamt.com

sidcha added 22 commits July 29, 2026 16:16
TRS tunnels raw smart-card APDUs from the CP, through a PD acting as
a transparent pipe, to a card in the reader (OSDP CMD_XWR/REPLY_XRD,
capability SMART_CARD_SUPPORT). The control panel drives the card
directly, so a compromised reader cannot forge a credential.

The CP runs the session as a library-driven state machine: it
negotiates transparent mode, streams the app's APDUs, and tears the
session down on osdp_cp_trs_stop(). Apps submit OSDP_CMD_XWR commands
and receive card responses as OSDP_EVENT_TRS events.

The PD answers each APDU either synchronously, in the command
callback, or deferred: it ACKs "working" and delivers the response on
a later poll once a slow card responds. Deferral keeps a shared
multi-drop bus non-blocking, so each PD's session makes progress
independently.

A Card Present reply is accepted with or without the trailing status
byte -- the spec marks it optional (v2.2 section 7.26.8) and readers
like the HID RPK40 omit it; the status then reports as unspecified.

Gated behind OPT_BUILD_OSDP_TRS (default off); wired into both build
systems and covered by a CP<->PD APDU round-trip unit test.

Fixes: #22
Co-developed-by: Aaron Tulino (Aaronjamt) <git@aaronjamt.com>
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Table 36 marks the mode-config byte optional: in its absence 0x00 is
used. The PD decoder demanded both mode and config, NAK-ing a 3-byte
Mode-Set that a foreign ACU may legitimately send.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The RPK40 NAKs a CARD_SCAN it does not implement with MSG_CHK, which
the CP's corrupted-frame heuristic answered with up to eight blind
retransmissions before tearing the card session down. Replaying card
traffic can have card-side effects (PIN retry counters, transaction
state), so CMD_XWR is exempt from the resend heuristic entirely; and
since a reader may pick any NAK code it fancies for an unimplemented
card command, every NAK to an app-owned card command is a soft
failure -- one failed command, session intact -- unless the code says
the link itself is broken (SEQ_NUM, SC_UNSUP, SC_COND).

The test runs on a plaintext link, as in the field trace: the mock
channel rewrites the reader's reply into a NAK(MSG_CHK) at the same
sequence number, which is also why the mock gained a no-SCBK device
setup -- rewriting a secured reply would desync the MAC chain, which
a real quirky reader never does.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
A reader left in transparent mode -- failed teardown, CP restart --
keeps answering polls with XRD replies. The CP treated those as
unknown responses and cycled the PD offline for the full retry
penalty, taking a healthy link down over a stale reader mode.

Swallow the reply with a warning instead: it is well-formed, it just
has no session to route to. The payload is dropped, no event reaches
the app, and the link stays up.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The 64-byte APDU carrier could not hold what the headline use case
moves: PIV certificate reads chain GET RESPONSE chunks up to the
short-APDU maximum of 258 bytes (255 data + SW1SW2).

Raise the default to 258 behind an #ifndef so constrained builds can
dial it back down -- osdp_cmd/osdp_event embed a buffer of this size,
so the override is also the memory knob. The usable size is further
bound by the negotiated packet size; a C-APDU that cannot fit it is
now rejected at submit time instead of failing mid-band. Full-size
chunks additionally need -DOSDP_PACKET_BUF_SIZE=512 (default 256).

Chunking inside the library would be wrong here: TRS is a transparent
tunnel and ISO 7816-4 has its own chaining.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The card (emu-card.c, libosdp-free) speaks just enough ISO 7816-4 to
model the headline workflow: SELECT of the PIV AID, GET DATA of a
1.2KB certificate-sized object, and 61xx/GET RESPONSE chaining.

The scenarios drive it through a real CP<->PD session over the mock
channel, modeled on the reader behaviour reported in issue #22: a
full chained certificate read, a session that must ride out a busy
spell, a permanently-busy reader costing one soft-failed command, and
a card leaving the field mid-band with no card-not-present courtesy
from the reader.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The channel-hook frame parsers assumed the leading mark byte from
build options, but its presence also varies by direction and RX mode;
detect it from the frame instead. Also add test-pd-zc.c to the lean
build's test sources -- the zero-copy unit-test binary never linked
there.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
A reader may announce a card in its field as an XRD poll response with
no TRS session open (v2.2 sections 5.11, 7.26.2, 7.26.8). Forward
Mode-0 card-info and Mode-1 card-present reports to the app as
OSDP_EVENT_TRS instead of discarding them; every other out-of-session
XRD is still shrugged off without cycling the PD offline.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Some readers (HID RPK40) stop reporting ordinary credentials while in
transparent mode and give no smart-card sighting in their default mode,
so neither mode alone can read both credential types. Add an opt-in,
library-managed scan (osdp_cp_trs_scan_enable) that time-slices the
reader: dwell in the default mode, hold transparent mode briefly
watching for a sighting, repeat. A sighting is forwarded as an
OSDP_EVENT_TRS event and transparent mode is held so the band the app
opens in response adopts the reader without renegotiating the mode.

The probe rides the existing TRS session machinery; pd->trs.probe only
suppresses session notifications and band flushing. Ordinary card or
keypad activity restarts the default-mode dwell so a probe never cuts
into an in-progress read. A refused probe raises
OSDP_TRS_SCAN_SUSPENDED and retries with exponential backoff.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
A failed mode-set does not prove the reader stayed in its default mode:
it may have entered transparent mode and had its reply lost or mangled
on the wire, and a reader stuck in mode 01 stops reporting ordinary
card reads. Mode-set(00) is idempotent, so always spend one teardown
round trip instead of assuming there is nothing to undo.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Scan cadence (silent mode flips, no notification/completion spam),
mode-0 activity deferring probes, disable/flush mid-probe, sighting
hold with band adoption (exactly one mode-set(01) on the wire from
enable to band close), refused-probe backoff with recovery, and an
emulated RPK40 workflow: standard read, smart-card sighting during a
probe, full chained PIV certificate read over the adopted session,
standard read again.

Related-to: #22
Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
hold_ms is documented as the window a probe keeps transparent mode open
for the app to answer a sighting with a START, but every sighting also
refreshed the deadline it is measured from. A reader is free to report a
card on every poll (v2.2 section 7.26.8), so a card left sitting in the
field renewed the hold indefinitely and kept the reader in mode 01, which
is precisely the state that stops ordinary credential reads.

Stamp the deadline on the first sighting only.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The presence scan only ever waited for a reader to volunteer a card
report on a poll. Readers are free to do that, but they are equally free
to latch a sighting for the rest of a transparent session, and several
do -- so a card lifted out of the field was never reported as gone and
an application had no way to find out short of sending card traffic of
its own.

The spec already has the question we were not asking: the mode-01 card
scan (v2.2 section 6.27.8) answers with a card-present report whose
status distinguishes present from not present (Table 73). Send it during
a probe, once when transparent mode comes up and once per mode-1 dwell
after that, so a removal is noticed within one scan cycle.

Nothing in PDCAP says whether a reader implements it, and one that does
not refuses in whatever way it likes, so treat any answer other than a
card-present report as a verdict: latch it and go back to poll-only for
the rest of the connection. Silence is not a verdict -- that is the link
failing, and it takes the usual session error path.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The presence statuses started at 1, leaving 0 unnamed. A card-present
event that never had a status decoded into it -- a zeroed struct, or an
error path that returned before the status byte was read -- therefore
held a value that is neither "present" nor "not present", and testing
for absence as "!= OSDP_TRS_CARD_NOT_PRESENT" read it as a real sighting.

Name it, and say in the header that presence must be tested against the
named values. The existing values keep their numbering: renumbering them
would silently change the meaning of an already-compiled application.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Now that the scan asks on a cadence, a card left sitting in the field
answers every probe the same way, and each answer was delivered to the
application as a fresh event. That is a stream of notifications saying
nothing has changed.

Report only what changed. Reports the reader volunteered on a poll are
left alone: those are the reader's to repeat, and an application that
missed the first one still needs to see the next.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
A reader that refuses the mode-01 card scan leaves the presence scan
unable to ask whether a card is still there, so removal is never
reported. That is a permanent property of the reader and it changes what
an application has to do -- find out for itself by sending a card
command and reading its failure as the card being gone -- but nothing
told it so.

Say it on the notification that already carries presence-scan news, once
per connection, for readers that refuse.

Nothing in PDCAP covers this, so it cannot go through the capability
report: the smart-card capability advertises transparent mode itself and
has no bit for the individual mode-01 commands.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
OSDP_TRS_APDU_MAX_LEN sizes the buffer in struct osdp_trs_apdu, but what
fits on the wire is bounded by the packet size in use and the framing the
APDU travels inside, which at the default packet size is appreciably
less. An application had no way to ask, so sizing payloads against the
constant -- the only number the header offered -- produced commands the
CP turned away at submit time.

Report the real limit.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
A send-APDU command was checked against the packet size when the
application submitted it, but a secure PIN entry carrying the same APDU
was not. Its APDU sits behind the PIN parameter block, so it has less
room, not more -- and an oversized one was only caught when the frame
was built, part way through a running band, where the application can no
longer do anything about it.

Check both, against the room each command actually leaves.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Enabling the presence scan on a PD that has no smart-card reader is a
configuration mistake worth reporting, and the check for it only ran if
the PD happened to be online at that moment. Applications enable the
scan once during setup, before any PD has been brought up, so it never
ran for anyone.

Check when the capability report actually arrives, and again on every
reconnect that re-collects it, keeping it to one warning per enable.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Transparent mode is the one piece of state a PD carries between
commands, and nothing dropped it when the CP restarted communication. A
PD that was left in mode 01 stays there, which is the same "reader stops
reporting ordinary card reads" condition the CP spends a round trip
avoiding on its own side -- reproduced by our own PD role.

Drop it on the sequence-number restart, and correct the comment there
that said the PD holds no state between commands.

Deliberately not on the PD's offline timeout: that measures silence, and
a CP working through a slow card transaction is silent for the same
reason it is still mid-session. Resetting there ends a session that is
merely slow, which the emulated-card busy test catches.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
The defaults were picked to make the scan responsive without field data
behind them. Running it on real hardware showed the cost lands on
ordinary credential reads: at 38400 baud a 75 ms default-mode dwell made
a reader miss card reads outright, and 200 ms was the value that worked.

Take the value that worked, and say in the header what makes it hard to
choose -- that shortening the default-mode dwell makes the scan more
aggressive rather than less, and that how short is too short depends on
the baud rate, because a probe costs round trips rather than time.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Teach both PD fixtures to answer the mode-1 card scan -- the emulated
card already knows whether it is in the field, so it answers from that --
and extend the wire-NAK hook to refuse or swallow only the card scan,
leaving every other transparent command alone.

The refusal and the silence cases must run in that order: a refusal
latches "no card scan" for the rest of the connection, so silence, which
must leave the verdict open, has to be proven while a verdict is still
reachable.

Signed-off-by: Siddharth Chandrasekaran <sidcha.dev@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant